controllers.WikiRealtime + logics.PageCursorHub)page.updated)view.scala.html)page.updated)이 문서는 현재 AhaWiki에 구현된 WebSocket 기능(페이지 뷰 화면의 실시간 커서 공유 + 페이지 갱신 알림)을 코드 기준으로 정리합니다.
GET /ws/w/*nameEncoded → controllers.WikiRealtime.watch(nameEncoded: String)./w/...)를 /ws/w/...로 치환해 WebSocket URL을 만들고,https면 wss://, 그 외에는 ws://를 사용합니다.즉, 사용자가 문서 보기 화면에 진입하면 브라우저 JS가 해당 문서용 WebSocket에 자동 연결을 시도합니다. 옛 리비전(?revision=N 이 최신이 아닐 때)을 보는 화면은 예외로, 연결하지 않습니다 — 까닭은 Dev Page 옛 리비전 화면.
controllers.WikiRealtime + logics.PageCursorHub)2026-08-10 에 Wiki 컨트롤러를 관심사별로 나누면서 watch 는 WikiRealtime 으로, 허브는 logics.PageCursorHub 로 갔다. 발행은 controllers.Wiki.save 에 남았고, 지금은 리비전을 쓰는 다른 경로들도 같은 자리를 통해 발행한다(아래 발행하는 경로).
wiki:<siteId>:<pageId> 형식입니다.siteId + pageId 조합으로 같은 이름의 문서를 분리합니다.PageCursorHubPageCursorHub는 서버 메모리 내 구독자 맵을 가지고 브로드캐스트를 수행합니다.
subscribers: TrieMap[roomKey, TrieMap[connectionId, PageSubscriber]]PageSubscriber는 queue(outbound queue)와 saveSenderId를 가짐.subscribe: room에 연결 추가unsubscribe: 연결 제거 및 queue completebroadcast: 발신자(connectionId) 제외하고 메시지 전송setSaveSenderId: 연결별 저장용 식별자 저장broadcastPageUpdated: 동일 saveSenderId 연결은 제외하고 page.updated 전송이 허브는 프로세스 메모리에 있고 인스턴스 사이에 직접 동기화가 없습니다. 2026-10-11 부터 운영은 평소 한 대라 아래 문제는 배포 중 둘이 함께 뜨는 동안에만 생깁니다(Dev Deploying «평소에는 한 대, 배포 때만 두 대»). 인스턴스 둘이 동시에 요청을 받을 때는 커서·접속자 표시는 저장한 요청과 같은 인스턴스에 소켓이 붙은 사람에게만 갑니다.
page.updated는 예외로, 2026-09-15 부터 Redis pub/sub 로 인스턴스 사이에 전달됩니다 — 그래서 새로고침 토스트와 Kanban 자동 동기화는 어느 인스턴스에 붙어 있든 옵니다(아래 인스턴스 사이 실시간 이벤트 절). 커서는 여전히 인스턴스 안에만 있고(고빈도라 그대로 둠), 놓친 커서·접속자 상태는 소켓이 다시 열릴 때의wiki:ws.connected재동기화로 따라잡습니다.
watch)watch(nameEncoded)에서 수행하는 핵심 로직:
nameEncoded를 decode해 페이지명 복원Site) 조회WikiPermission.isReadable) 검사Forbidden("Permission denied.") 반환이로 인해 읽기 권한이 없는 페이지는 소켓 연결 자체가 거부됩니다.
서버 sink는 inbound JSON의 type에 따라 아래를 처리합니다.
cursor.move{ type: "cursor.move", x, y }x, y는 숫자로 읽고 각각 [0.0, 1.0] 범위로 clamptype, siteId, pageId, senderId, x, y, tscursor.hello{ type: "cursor.hello", saveSenderId? }saveSenderId를 연결 상태에 저장senderId, 사용자 nickname, profileImageUrl을 담은 hello를 타 사용자에게 브로드캐스트cursor.ping{ type: "cursor.ping" }mapMaterializedValue)cursor.hello 1회 pushcursor.hello 브로드캐스트watchTermination)page.updated)새 리비전을 쓰는 경로는 저장이 성공한 뒤 다음 payload를 만들어 브로드캐스트합니다.
type: "page.updated"pageNamerevision (새 리비전)editorNicknamedateInserted전송 방식은 CrossInstanceBus.publishPageUpdated(siteSeq, pageName, revision, editorNickname, dateInserted, saveSenderId) 하나입니다. room 키와 위 payload는 그 안에서 만듭니다(CrossInstanceBus.pageUpdatedPayload) — 호출부마다 payload를 조립하면 필드 하나가 한쪽에서만 바뀌는 날이 옵니다.
saveSenderId와 동일한 saveSenderId를 가진 구독자에게는 알림을 제외합니다.controllers.Wiki.save — 웹 편집기·Kanban 저장. saveSenderId를 함께 보내므로 저장한 탭은 제외됩니다.controllers.ApiV1.savePage — API Key 저장(Api). 2026-09-25에 추가했습니다.controllers.Diary.write — 오늘 날짜 페이지에 한 줄 덧붙이기. 2026-09-25에 추가했습니다.controllers.WikiSpreadsheet.syncGoogleSpreadsheet — 스프레드시트를 표로 끌어오기. 2026-09-25에 추가했습니다.뒤 셋은 saveSenderId가 없습니다. 저장한 쪽이 그 페이지를 보고 있는 브라우저가 아니기 때문입니다(스크립트이거나, 저장 뒤 그 페이지로 이동합니다). 2026-09-25 전에는 이 셋이 아예 발행하지 않아서, 봇이 고친 페이지를 열어 둔 사람은 소켓이 다시 열릴 때까지 낡은 화면을 봤습니다.
rename 은 발행하지 않습니다. 원래 이름 자리에 남는 것은 #!redirect 한 줄짜리 리비전 1 이라, 「누가 이 페이지를 고쳤습니다, 새로고침할까요」 토스트를 띄우면 새로고침이 리다이렉트로 떨어집니다. 이동은 갱신과 다른 사건이고, 알리려면 제 타입이 있어야 합니다.
view.scala.html)문서 보기 페이지 JS가 WebSocket을 담당합니다.
connect()에서 new WebSocket(wsUrl) 생성onopen 시 saveSenderId를 생성 후 cursor.hello 전송 (브라우저 쿠키 ID + 탭별 sessionStorage ID 조합)saveSenderId는 브라우저 공통 ID(쿠키) + 탭 고유 ID(sessionStorage)를 결합해, 같은 브라우저의 다른 탭은 서로 다른 값이 되도록 합니다. 쿠키 수명은 view.scala.html 의 senderIdCookieMaxAgeSeconds(현재 1년)입니다. 같은 쿠키를 edit.page.js(cookieMaxAgeSeconds)와 AhaWiki.Kanban.js(SENDER_ID_COOKIE_MAX_AGE_SECONDS)도 같은 값으로 다시 씁니다 — 한 곳만 바꾸면 다른 곳이 되돌립니다.onopen 은 wiki:ws.connected DOM CustomEvent(detail.pageName)도 발행합니다. Kanban 이 이것을 받아 끊긴 사이 놓친 저장을 따라잡습니다 — Dev Kanban Realtime 참고.onopen 뒤 30초마다 cursor.ping 을 보냅니다(간격은 view.scala.html 의 setInterval 인자). onclose 에서 그 타이머를 지웁니다.onclose 시 재연결한다 — 어떻게는 열렸던 소켓인지에 달렸다. 열렸다가 끊긴 소켓(idle timeout, 배포)은 reconnectDelayMs(1.5초) 뒤에 다시 연다. 한 번도 안 열린 소켓은 핸드셰이크에서 거절된 것이다 — 서버가 읽을 수 없는 페이지에 403 을 답하면 브라우저에는 close 로만 보인다 — 이때는 지연을 두 배씩 늘리며(1.5·3·6·12초) 다섯 번째 거절에서 그만둔다(handshakeGiveUpAfter). 열리면 그 횟수는 0 으로 돌아간다. 2026-09-26 전에는 구분 없이 1.5초마다 무한히 다시 열었고, 8월에 한 주소가 44,249번 두드렸다(Dev Logs). 판정 함수는 test/wiki-view.reconnect.test.mjs 가 템플릿에서 그대로 읽어 검사한다.onerror/onclose는 console warn 로그 출력cursor.hellosenderId를 기준으로 상대 커서 메타(nickname, profileImageUrl) 저장senderId 확정 로직 수행cursor.hello를 다시 보냄cursor.moveupsertCursor(senderId, x, y) 호출requestAnimationFrame)가 보간(smoothing) 이동 처리page.updatedrevision이 유효하고, 마지막으로 본 revision과 다를 때만 처리${editorNickname} updated this page. Would you like to refresh? 토스트 노출cursor.move 전송e.pageX / window.width, e.pageY / window.height로 정규화하여 [0,1] 범위로 보냄클라이언트 → 서버:
cursor.hello{ "type": "cursor.hello", "saveSenderId": "..." }
cursor.move{ "type": "cursor.move", "x": 0.42, "y": 0.77 }
cursor.ping (30초마다){ "type": "cursor.ping" }
서버 → 클라이언트:
cursor.hello{ "type": "cursor.hello", "senderId": "...", "nickname": "...", "profileImageUrl": "..." }
cursor.move{ "type": "cursor.move", "siteId": 1, "pageId": "Home", "senderId": "...", "x": 0.42, "y": 0.77, "ts": 1710000000000 }
page.updated{ "type": "page.updated", "pageName": "Home", "revision": 12, "editorNickname": "Alice", "dateInserted": "2026-05-11T10:20:30" }
Source.queue[String](32, OverflowStrategy.dropHead)이므로 소비 지연 시 오래된 outbound 메시지가 버려질 수 있습니다.saveSenderId를 함께 전송합니다. 서버에서 409 Conflict를 반환하면 최신 revision을 재조회 후 자동 재시도하고, 모두 실패 시 non-blocking 토스트를 표시합니다 (페이지 강제 새로고침 없음). 재시도 횟수는 AhaWiki.Kanban.js 의 requestSaveKanban 안 attempt 상한(현재 3회)입니다.page.updated 이벤트를 칸반 실시간 동기화에 활용합니다 — 자세한 내용은 Dev Kanban Realtime 참조.page.updated)같은 페이지를 여러 사용자가 동시에 보고 있을 때, **다른 사용자의 저장**을 감지해 새로고침을 유도하는 기능입니다.
페이지 저장 성공 시점에 해당 페이지 room으로 page.updated 이벤트를 broadcast 합니다. 어느 경로가 발행하고 payload에 무엇이 담기는지는 위 저장 시 페이지 갱신 이벤트 에 한 번만 적어 둡니다.
Wiki/view 페이지의 WebSocket onmessage에서 page.updated 이벤트를 처리합니다.
${editorNickname} updated this page. Would you like to refresh?Refresh → 클릭 시 window.location.reload() 실행.InterpreterKanban 이 있는 페이지는 2초 기다렸다가 Kanban 동기화가 안 됐을 때만 띄운다 — Dev Kanban Realtime 의 토스트 억제 참고.wiki:page.updated DOM CustomEvent 발행page.updated 수신 후 document.dispatchEvent(new CustomEvent('wiki:page.updated', { detail: { pageName, revision, editorNickname } })) 를 발행합니다.
이를 통해 페이지에 임베드된 컴포넌트(예: Kanban)가 자신의 업데이트 로직을 독립적으로 구독할 수 있습니다.
AhaWiki.Kanban.js가 wiki:page.updated 이벤트를 수신하면 보드를 자동으로 갱신합니다.
GET /w/{page}?action=raw 으로 최신 원문 fetchparseKanbanTextrerenderColumns자세한 내용: Dev Kanban Realtime
revision에 대해서는 토스트를 1회만 표시page.updated 이벤트를 페이지 room에 전송합니다.Refresh를 누르면 페이지가 새로고침되어 최신 revision을 확인할 수 있습니다.page.updated 이벤트를 전송합니다.view.scala.html이 wiki:page.updated DOM CustomEvent를 발행합니다.AhaWiki.Kanban.js가 이벤트를 수신 → 원문 fetch → 병합 → 보드 자동 갱신.page.updated 를 Redis pub/sub 로 인스턴스 사이에 전달한다. 소유자가 «Redis pub/sub·page.updated 만» 으로 정해 구현했고, 2026-09-15 에 양쪽 인스턴스에 배포했다. 코드는 logics.CrossInstanceBus(신규) 와 controllers.Wiki.save 의 호출부, Module 의 eager 바인딩. 아래는 무엇을·왜 그렇게 했는지의 기록이다.
PageCursorHub 는 프로세스 메모리이고 운영은 인스턴스 둘이 요청을 나눠 받았다(2026-10-11 까지 — 지금은 평소 한 대, Dev Deploying). 그래서 한 인스턴스에서 일어난 저장의 page.updated 와 커서는 같은 인스턴스에 소켓이 붙은 사람에게만 간다. 다른 쪽 사람은 소켓이 다시 열릴 때의 wiki:ws.connected 재동기화로만 따라잡는다(Dev Kanban Realtime). 값이 큰 손실은 page.updated 다 — Kanban 실시간 동기화와 새로고침 알림이 절반의 독자에게 안 간다. 커서는 «편의» 라 손실이 가볍다.
page.updated — 저빈도(저장할 때만), 값 큼. → 인스턴스 사이로 보낸다.cursor.move — 고빈도(사용자당 50ms throttle), 값 작음. → 인스턴스 안에만 둔다(권장). 이걸 브로커로 보내면 커서 한 번마다 전 인스턴스로 fan-out 되어 부하가 급증한다.cursor.hello(접속자 표시) — 중간. 나중에 필요하면 추가.권장: 우선 page.updated 만 인스턴스 사이로. 커서는 지금처럼 인스턴스 안에.
이벤트는 일시적이다. page.updated 는 클라이언트가 revision 으로 중복·역순을 무시하고, 커서는 last-write-wins 다. 그래서 at-most-once·순서 무보장·중복 허용 으로 충분하다 — durable queue 가 필요 없다. 브로커가 죽으면 인스턴스 사이 전달만 멈추고 «현재 동작(인스턴스 안)» 으로 degrade 한다. 재연결 catch-up 이 있으므로 정합성 손실은 없다.
play-redis; ehcache 는 build.sbt 에서 주석). 두 인스턴스가 같은 Redis 를 보므로 공유 브로커가 이미 있다. 각 인스턴스가 채널 하나를 SUBSCRIBE 하고, 저장 시 로컬 broadcast + 채널에 PUBLISH 한다. 표준적이고 N 인스턴스로 확장되며 토폴로지 가정이 없다. 비용: play-redis 는 pub/sub 를 노출하지 않으므로 pub/sub 전용 연결(예: Jedis 의 blocking subscribe 스레드, 또는 Lettuce) 하나를 새로 둬야 한다.FilterAccessLog 의 ipWhitelist 처럼).권장: 1(Redis pub/sub). 이미 공유 Redis 가 있어 새 토폴로지 가정이 없고 표준적이다. 새 의존성(pub/sub 클라이언트) 하나가 유일한 비용. 새 의존성을 피하고 싶으면 2 도 지금의 «동일호스트 2 인스턴스» 구조에 잘 맞는다.
CrossInstanceBus, 또는 PageCursorHub 확장):publishPageUpdated(roomKey, saveSenderId, payload): 지금처럼 로컬 broadcastPageUpdated 를 부르고, 이어서 {roomKey, saveSenderId, payload, origin=instanceId} 를 채널에 PUBLISH.SUBSCRIBE → 수신 메시지의 origin 이 자기면 무시(로컬로 이미 보냄), 아니면 PageCursorHub.broadcastPageUpdated(roomKey, saveSenderId, payload) 만 호출(재PUBLISH 안 함).instanceId 는 기동 시 UUID(또는 포트).controllers.Wiki.save 의 호출부(현재 broadcastPageUpdated 직접 호출, Wiki.scala)를 publishPageUpdated 로 바꾼다.saveSenderId 를 payload 에 실어 상대 인스턴스도 저장자의 다른 탭을 제외하게 한다(현재 로컬 제외 규칙과 동일).PUBLISH/SUBSCRIBE 에러는 로그만 남기고 로컬 동작은 그대로.page.updated 만. 커서는 인스턴스 안에 그대로 둔다(고빈도·편의).localhost:6379, db0)를 쓴다(캐시 Redis 그대로 재사용). 채널은 ahawiki:0:page.updated.play-redis 는 pub/sub 를 노출하지 않음). publish 는 JedisPooled, subscribe 는 데몬 스레드에서 blocking subscribe + 끊기면 2초 후 재접속.PUBLISH/SUBSCRIBE 오류는 로그만 남기고 로컬 동작은 유지 — Redis 가 죽으면 «인스턴스 안» 으로 degrade. 테스트 모드에선 버스를 끈다(Redis 없음).CrossInstanceBusUnit 가 전송 형식(encode/decode 왕복, saveSenderId 유무, 잘못된 입력)을 덮는다. Scala 258 통과.SUBSCRIBE(PUBSUB NUMSUB = 2) 하고, 채널에 넣은 page.updated 하나가 10001·10000 양쪽에 붙은 소켓에 모두 도달함을 확인. publish 는 Wiki.save 에 연결되어 있고 로그로 «CrossInstanceBus on» 확인.page.updated 는 저빈도라 Redis 부하는 무시할 만.배포 당시 발행하는 곳은 Wiki.save 하나였다. API·Diary·Spreadsheet 저장은 리비전을 쓰면서도 아무에게도 알리지 않았다 — 인스턴스 사이가 아니라 같은 인스턴스 안에서도 안 갔다. 위키 문서 sync 가 API 로 도는 지금은 그게 가장 흔한 저장이라, 봇이 고친 페이지를 열어 둔 사람만 낡은 화면을 보고 있었다.
세 경로에 발행을 넣으면서 payload 조립을 CrossInstanceBus 안으로 옮겼다. 호출부가 넷이 되는 순간 «같은 JSON 을 네 군데서 만든다» 가 되고, 그중 하나만 고쳐지는 날이 온다. 지금은 호출부가 한 줄씩이다. 단위테스트 CrossInstanceBusUnit 가 payload 필드와 타입(특히 revision 이 문자열이 아니라 수)을 덮는다.
Similar pages by cosine similarity. Words after page name are term frequency.